page.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. "use client";
  2. import { useEffect, useState } from "react";
  3. import { useSession } from "next-auth/react";
  4. import { redirect, useRouter } from "next/navigation";
  5. import Link from "next/link";
  6. import AuthenticatedLayout from "@/components/AuthenticatedLayout";
  7. import { AppointmentStatusBadge } from "@/components/appointments/AppointmentStatusBadge";
  8. import RecordsModal from "@/components/records/RecordsModal";
  9. import { Button } from "@/components/ui/button";
  10. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
  11. import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
  12. import { Separator } from "@/components/ui/separator";
  13. import {
  14. Dialog,
  15. DialogContent,
  16. DialogDescription,
  17. DialogFooter,
  18. DialogHeader,
  19. DialogTitle,
  20. } from "@/components/ui/dialog";
  21. import { Textarea } from "@/components/ui/textarea";
  22. import { Label } from "@/components/ui/label";
  23. import { ApproveAppointmentModal } from "@/components/appointments/ApproveAppointmentModal";
  24. import {
  25. Calendar,
  26. Clock,
  27. User,
  28. FileText,
  29. Video,
  30. CheckCircle2,
  31. XCircle,
  32. ArrowLeft,
  33. Loader2,
  34. AlertCircle,
  35. } from "lucide-react";
  36. import { format } from "date-fns";
  37. import { es } from "date-fns/locale";
  38. import { notifications } from "@/lib/notifications";
  39. import type { Appointment } from "@/types/appointments";
  40. import type { Record } from "@/components/records/types";
  41. import { canJoinMeeting, getAppointmentTimeStatus } from "@/utils/appointments";
  42. interface PageProps {
  43. params: Promise<{ id: string }>;
  44. }
  45. export default function AppointmentDetailPage({ params }: PageProps) {
  46. const router = useRouter();
  47. const { data: session, status } = useSession();
  48. const [appointment, setAppointment] = useState<Appointment | null>(null);
  49. const [loading, setLoading] = useState(true);
  50. const [approveDialog, setApproveDialog] = useState(false);
  51. const [rejectDialog, setRejectDialog] = useState(false);
  52. const [motivoRechazo, setMotivoRechazo] = useState("");
  53. const [actionLoading, setActionLoading] = useState(false);
  54. const [appointmentId, setAppointmentId] = useState<string>("");
  55. const [showRecordsModal, setShowRecordsModal] = useState(false);
  56. useEffect(() => {
  57. const loadParams = async () => {
  58. const resolvedParams = await params;
  59. setAppointmentId(resolvedParams.id);
  60. };
  61. loadParams();
  62. }, [params]);
  63. useEffect(() => {
  64. if (!appointmentId) return;
  65. const fetchAppointment = async () => {
  66. try {
  67. const response = await fetch(`/api/appointments/${appointmentId}`);
  68. if (!response.ok) {
  69. throw new Error("No se pudo cargar la cita");
  70. }
  71. const data: Appointment = await response.json();
  72. setAppointment(data);
  73. } catch (error) {
  74. notifications.appointments.loadError();
  75. console.error(error);
  76. } finally {
  77. setLoading(false);
  78. }
  79. };
  80. fetchAppointment();
  81. }, [appointmentId]);
  82. if (status === "loading" || loading) {
  83. return (
  84. <AuthenticatedLayout>
  85. <div className="flex items-center justify-center min-h-screen">
  86. <Loader2 className="h-8 w-8 animate-spin" />
  87. </div>
  88. </AuthenticatedLayout>
  89. );
  90. }
  91. if (!session) {
  92. redirect("/auth/login");
  93. }
  94. if (!appointment) {
  95. return (
  96. <AuthenticatedLayout>
  97. <div className="container mx-auto px-4 py-6">
  98. <Card>
  99. <CardContent className="flex flex-col items-center justify-center py-12">
  100. <AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
  101. <h3 className="text-lg font-semibold mb-2">Cita no encontrada</h3>
  102. <p className="text-muted-foreground mb-4">
  103. La cita que buscas no existe o no tienes permisos para verla.
  104. </p>
  105. <Button asChild>
  106. <Link href="/appointments">Volver a mis citas</Link>
  107. </Button>
  108. </CardContent>
  109. </Card>
  110. </div>
  111. </AuthenticatedLayout>
  112. );
  113. }
  114. const userRole = session.user.role as "PATIENT" | "DOCTOR" | "ADMIN";
  115. const isPatient = userRole === "PATIENT";
  116. const isDoctor = userRole === "DOCTOR";
  117. const otherUser = isPatient ? appointment.medico : appointment.paciente;
  118. const hasFecha = appointment.fechaSolicitada !== null;
  119. const fecha = hasFecha ? new Date(appointment.fechaSolicitada!) : null;
  120. const handleApprove = async (fechaSolicitada: Date, notas?: string) => {
  121. setActionLoading(true);
  122. try {
  123. const response = await fetch(`/api/appointments/${appointment.id}/approve`, {
  124. method: "POST",
  125. headers: {
  126. "Content-Type": "application/json",
  127. },
  128. body: JSON.stringify({
  129. fechaSolicitada: fechaSolicitada.toISOString(),
  130. notas,
  131. }),
  132. });
  133. if (!response.ok) {
  134. const error = await response.json();
  135. throw new Error(error.error || "Error al aprobar la cita");
  136. }
  137. const updated: Appointment = await response.json();
  138. setAppointment(updated);
  139. setApproveDialog(false);
  140. notifications.appointments.approved();
  141. } catch (error) {
  142. notifications.appointments.approveError(error instanceof Error ? error.message : undefined);
  143. console.error(error);
  144. } finally {
  145. setActionLoading(false);
  146. }
  147. };
  148. const handleRejectConfirm = async () => {
  149. if (!motivoRechazo.trim()) return;
  150. setActionLoading(true);
  151. try {
  152. const response = await fetch(`/api/appointments/${appointment.id}/reject`, {
  153. method: "POST",
  154. headers: { "Content-Type": "application/json" },
  155. body: JSON.stringify({ motivoRechazo }),
  156. });
  157. if (!response.ok) throw new Error("Error al rechazar la cita");
  158. const updated: Appointment = await response.json();
  159. setAppointment(updated);
  160. setRejectDialog(false);
  161. setMotivoRechazo("");
  162. notifications.appointments.rejected();
  163. } catch (error) {
  164. notifications.appointments.rejectError();
  165. console.error(error);
  166. } finally {
  167. setActionLoading(false);
  168. }
  169. };
  170. const handleCopyContent = (content: string) => {
  171. navigator.clipboard.writeText(content);
  172. notifications.records.copied();
  173. };
  174. const handleDownloadReport = (record: Record) => {
  175. const blob = new Blob([record.content], { type: "text/plain" });
  176. const url = URL.createObjectURL(blob);
  177. const a = document.createElement("a");
  178. a.href = url;
  179. a.download = `reporte-medico-${record.id.slice(-8)}-${new Date().toISOString().split("T")[0]}.txt`;
  180. document.body.appendChild(a);
  181. a.click();
  182. document.body.removeChild(a);
  183. URL.revokeObjectURL(url);
  184. notifications.records.downloaded();
  185. };
  186. const handleGeneratePDF = async (record: Record) => {
  187. // TODO: Implementar generación de PDF
  188. notifications.records.generated();
  189. };
  190. const handleCancel = async () => {
  191. setActionLoading(true);
  192. try {
  193. const response = await fetch(`/api/appointments/${appointment.id}`, {
  194. method: "DELETE",
  195. });
  196. if (!response.ok) throw new Error("Error al cancelar la cita");
  197. notifications.appointments.cancelled();
  198. router.push("/appointments");
  199. } catch (error) {
  200. notifications.appointments.cancelError();
  201. console.error(error);
  202. setActionLoading(false);
  203. }
  204. };
  205. const handleStartMeeting = async () => {
  206. setActionLoading(true);
  207. try {
  208. const response = await fetch(`/api/appointments/${appointment.id}/start-meeting`, {
  209. method: "POST",
  210. });
  211. if (!response.ok) {
  212. const error = await response.json();
  213. throw new Error(error.message || error.error || "No se puede iniciar la videollamada");
  214. }
  215. const data = await response.json();
  216. // Redirigir a la sala de Jitsi
  217. router.push(`/appointments/${appointment.id}/meet`);
  218. } catch (error) {
  219. notifications.appointments.videocallError(error instanceof Error ? error.message : undefined);
  220. console.error(error);
  221. } finally {
  222. setActionLoading(false);
  223. }
  224. };
  225. const handleComplete = async () => {
  226. setActionLoading(true);
  227. try {
  228. const response = await fetch(`/api/appointments/${appointment.id}/complete`, {
  229. method: "POST",
  230. });
  231. if (!response.ok) throw new Error("Error al completar la cita");
  232. const updated: Appointment = await response.json();
  233. setAppointment(updated);
  234. notifications.appointments.completed();
  235. } catch (error) {
  236. notifications.appointments.completeError();
  237. console.error(error);
  238. } finally {
  239. setActionLoading(false);
  240. }
  241. };
  242. return (
  243. <AuthenticatedLayout>
  244. <div className="container mx-auto px-4 py-6 max-w-4xl">
  245. {/* Back Button */}
  246. <Button
  247. variant="ghost"
  248. className="mb-4"
  249. onClick={() => router.back()}
  250. >
  251. <ArrowLeft className="h-4 w-4 mr-2" />
  252. Volver
  253. </Button>
  254. {/* Header Card */}
  255. <Card className="mb-6">
  256. <CardHeader>
  257. <div className="flex items-center justify-between">
  258. <div className="flex items-center gap-4">
  259. {otherUser && (
  260. <Avatar className="h-16 w-16">
  261. <AvatarImage src={otherUser.profileImage || undefined} />
  262. <AvatarFallback className="text-lg">
  263. {otherUser.name[0]}{otherUser.lastname[0]}
  264. </AvatarFallback>
  265. </Avatar>
  266. )}
  267. <div>
  268. <CardTitle className="text-2xl">
  269. {otherUser
  270. ? `${otherUser.name} ${otherUser.lastname}`
  271. : isDoctor
  272. ? "Sin asignar"
  273. : "Médico por asignar"}
  274. </CardTitle>
  275. <CardDescription>
  276. {isPatient ? "Médico asignado" : "Paciente"}
  277. </CardDescription>
  278. </div>
  279. </div>
  280. <AppointmentStatusBadge status={appointment.estado} />
  281. </div>
  282. </CardHeader>
  283. </Card>
  284. {/* Details Card */}
  285. <Card className="mb-6">
  286. <CardHeader>
  287. <CardTitle>Detalles de la Cita</CardTitle>
  288. </CardHeader>
  289. <CardContent className="space-y-4">
  290. {hasFecha && fecha ? (
  291. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  292. <div className="flex items-start gap-3">
  293. <Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
  294. <div>
  295. <p className="text-sm font-medium">Fecha</p>
  296. <p className="text-sm text-muted-foreground">
  297. {format(fecha, "PPP", { locale: es })}
  298. </p>
  299. </div>
  300. </div>
  301. <div className="flex items-start gap-3">
  302. <Clock className="h-5 w-5 text-muted-foreground mt-0.5" />
  303. <div>
  304. <p className="text-sm font-medium">Hora</p>
  305. <p className="text-sm text-muted-foreground">
  306. {format(fecha, "p", { locale: es })}
  307. </p>
  308. </div>
  309. </div>
  310. </div>
  311. ) : (
  312. <div className="bg-muted/50 p-4 rounded-lg">
  313. <div className="flex items-start gap-3">
  314. <Clock className="h-5 w-5 text-muted-foreground mt-0.5" />
  315. <div>
  316. <p className="text-sm font-medium">Fecha y hora</p>
  317. <p className="text-sm text-muted-foreground italic">
  318. {appointment.estado === "PENDIENTE"
  319. ? "Pendiente de asignación por el médico"
  320. : "No asignada"}
  321. </p>
  322. </div>
  323. </div>
  324. </div>
  325. )}
  326. <Separator />
  327. <div className="flex items-start gap-3">
  328. <FileText className="h-5 w-5 text-muted-foreground mt-0.5" />
  329. <div className="flex-1">
  330. <p className="text-sm font-medium mb-1">Motivo de consulta</p>
  331. <p className="text-sm text-muted-foreground">
  332. {appointment.motivoConsulta}
  333. </p>
  334. </div>
  335. </div>
  336. {appointment.motivoRechazo && (
  337. <>
  338. <Separator />
  339. <div className="bg-destructive/10 p-4 rounded-lg">
  340. <div className="flex items-start gap-3">
  341. <XCircle className="h-5 w-5 text-destructive mt-0.5" />
  342. <div className="flex-1">
  343. <p className="text-sm font-medium text-destructive mb-1">
  344. Motivo de rechazo
  345. </p>
  346. <p className="text-sm text-muted-foreground">
  347. {appointment.motivoRechazo}
  348. </p>
  349. </div>
  350. </div>
  351. </div>
  352. </>
  353. )}
  354. {/* Solo mostrar sala si NO está completada */}
  355. {appointment.roomName && appointment.estado !== "COMPLETADA" && (
  356. <>
  357. <Separator />
  358. <div className="bg-primary/10 p-4 rounded-lg">
  359. <div className="flex items-start gap-3">
  360. <Video className="h-5 w-5 text-primary mt-0.5" />
  361. <div className="flex-1">
  362. <p className="text-sm font-medium mb-1">Sala de videollamada</p>
  363. <p className="text-sm text-muted-foreground mb-3">
  364. La sala está lista. Puedes unirte cuando llegue la hora de la cita.
  365. </p>
  366. <Button asChild size="sm">
  367. <Link href={`/appointments/${appointment.id}/meet`}>
  368. <Video className="h-4 w-4 mr-2" />
  369. Unirse a la consulta
  370. </Link>
  371. </Button>
  372. </div>
  373. </div>
  374. </div>
  375. </>
  376. )}
  377. {appointment.notasGuardadas && appointment.notasConsulta && (
  378. <>
  379. <Separator />
  380. <div className="bg-green-50 dark:bg-green-800 p-4 rounded-lg">
  381. <div className="flex items-start gap-3">
  382. <FileText className="h-5 w-5 text-green-700 dark:text-green-400 mt-0.5" />
  383. <div className="flex-1">
  384. <p className="text-sm font-medium text-green-900 dark:text-green-100 mb-1">
  385. Notas de la Consulta
  386. </p>
  387. {appointment.notasGuardadasAt && (
  388. <p className="text-xs text-green-700 dark:text-green-300 mb-2">
  389. Guardadas el {format(new Date(appointment.notasGuardadasAt), "d 'de' MMMM 'a las' HH:mm", { locale: es })}
  390. </p>
  391. )}
  392. <div className="text-sm text-green-900 dark:text-green-100 whitespace-pre-wrap bg-white/50 dark:bg-black/20 p-3 rounded">
  393. {appointment.notasConsulta}
  394. </div>
  395. </div>
  396. </div>
  397. </div>
  398. </>
  399. )}
  400. </CardContent>
  401. </Card>
  402. {/* Report Card - Si existe reporte asociado */}
  403. {appointment.record && (
  404. <Card className="mb-6">
  405. <CardHeader>
  406. <CardTitle>Reporte Médico Asociado</CardTitle>
  407. <CardDescription>
  408. Reporte generado el {format(
  409. typeof appointment.record.createdAt === "string"
  410. ? new Date(appointment.record.createdAt)
  411. : appointment.record.createdAt,
  412. "d 'de' MMMM 'de' yyyy 'a las' HH:mm",
  413. { locale: es }
  414. )}
  415. </CardDescription>
  416. </CardHeader>
  417. <CardContent>
  418. <Button
  419. onClick={() => setShowRecordsModal(true)}
  420. variant="outline"
  421. className="w-full"
  422. >
  423. <FileText className="w-4 h-4 mr-2" />
  424. Ver Reporte Médico Completo
  425. </Button>
  426. </CardContent>
  427. </Card>
  428. )}
  429. {/* Actions Card */}
  430. <Card>
  431. <CardHeader>
  432. <CardTitle>Acciones</CardTitle>
  433. </CardHeader>
  434. <CardContent>
  435. <div className="flex flex-wrap gap-3">
  436. {isDoctor && appointment.estado === "PENDIENTE" && (
  437. <>
  438. <Button
  439. onClick={() => setApproveDialog(true)}
  440. disabled={actionLoading}
  441. className="flex-1 min-w-[150px]"
  442. >
  443. {actionLoading ? (
  444. <Loader2 className="h-4 w-4 mr-2 animate-spin" />
  445. ) : (
  446. <CheckCircle2 className="h-4 w-4 mr-2" />
  447. )}
  448. Aprobar Cita
  449. </Button>
  450. <Button
  451. onClick={() => setRejectDialog(true)}
  452. variant="destructive"
  453. disabled={actionLoading}
  454. className="flex-1 min-w-[150px]"
  455. >
  456. <XCircle className="h-4 w-4 mr-2" />
  457. Rechazar Cita
  458. </Button>
  459. </>
  460. )}
  461. {isDoctor && appointment.estado === "APROBADA" && (
  462. <>
  463. {canJoinMeeting(appointment.fechaSolicitada).canJoin ? (
  464. <Button
  465. onClick={handleStartMeeting}
  466. disabled={actionLoading}
  467. className="flex-1 min-w-[150px]"
  468. >
  469. {actionLoading ? (
  470. <Loader2 className="h-4 w-4 mr-2 animate-spin" />
  471. ) : (
  472. <Video className="h-4 w-4 mr-2" />
  473. )}
  474. Unirse a Videollamada
  475. </Button>
  476. ) : (
  477. <Button
  478. disabled
  479. variant="outline"
  480. className="flex-1 min-w-[150px]"
  481. >
  482. <Clock className="h-4 w-4 mr-2" />
  483. {getAppointmentTimeStatus(appointment.fechaSolicitada)}
  484. </Button>
  485. )}
  486. <Button
  487. onClick={handleComplete}
  488. disabled={actionLoading}
  489. variant="outline"
  490. className="flex-1 min-w-[150px]"
  491. >
  492. {actionLoading ? (
  493. <Loader2 className="h-4 w-4 mr-2 animate-spin" />
  494. ) : (
  495. <CheckCircle2 className="h-4 w-4 mr-2" />
  496. )}
  497. Marcar como Completada
  498. </Button>
  499. </>
  500. )}
  501. {isPatient && appointment.estado === "PENDIENTE" && (
  502. <Button
  503. onClick={handleCancel}
  504. variant="outline"
  505. disabled={actionLoading}
  506. className="flex-1 min-w-[150px]"
  507. >
  508. {actionLoading ? (
  509. <Loader2 className="h-4 w-4 mr-2 animate-spin" />
  510. ) : (
  511. <XCircle className="h-4 w-4 mr-2" />
  512. )}
  513. Cancelar Cita
  514. </Button>
  515. )}
  516. {isPatient && appointment.estado === "APROBADA" && (
  517. <>
  518. {canJoinMeeting(appointment.fechaSolicitada).canJoin ? (
  519. <Button
  520. onClick={handleStartMeeting}
  521. disabled={actionLoading}
  522. className="flex-1 min-w-[150px]"
  523. >
  524. {actionLoading ? (
  525. <Loader2 className="h-4 w-4 mr-2 animate-spin" />
  526. ) : (
  527. <Video className="h-4 w-4 mr-2" />
  528. )}
  529. Unirse a Videollamada
  530. </Button>
  531. ) : (
  532. <Button
  533. disabled
  534. variant="outline"
  535. className="flex-1 min-w-[150px]"
  536. >
  537. <Clock className="h-4 w-4 mr-2" />
  538. {getAppointmentTimeStatus(appointment.fechaSolicitada)}
  539. </Button>
  540. )}
  541. </>
  542. )}
  543. {/* Acciones para citas completadas */}
  544. {appointment.estado === "COMPLETADA" && (
  545. <>
  546. {appointment.notasGuardadas && appointment.notasConsulta ? (
  547. <div className="flex-1 min-w-[150px] bg-green-50 dark:bg-green-800 p-4 rounded-lg">
  548. <div className="flex items-center gap-2 mb-2">
  549. <CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
  550. <p className="text-sm font-medium text-green-900 dark:text-green-100">
  551. Consulta Finalizada
  552. </p>
  553. </div>
  554. <p className="text-xs text-green-700 dark:text-green-300 mb-3">
  555. Las notas de la consulta están disponibles arriba
  556. </p>
  557. </div>
  558. ) : (
  559. <div className="flex-1 min-w-[150px] bg-muted p-4 rounded-lg">
  560. <div className="flex items-center gap-2 mb-2">
  561. <CheckCircle2 className="h-5 w-5 text-muted-foreground" />
  562. <p className="text-sm font-medium">
  563. Consulta Finalizada
  564. </p>
  565. </div>
  566. <p className="text-xs text-muted-foreground">
  567. Esta cita ha sido completada
  568. </p>
  569. </div>
  570. )}
  571. </>
  572. )}
  573. {/* Botón genérico de unirse - REMOVIDO: los botones específicos arriba cubren todos los casos necesarios */}
  574. </div>
  575. </CardContent>
  576. </Card>
  577. {/* Reject Dialog */}
  578. <Dialog open={rejectDialog} onOpenChange={setRejectDialog}>
  579. <DialogContent>
  580. <DialogHeader>
  581. <DialogTitle>Rechazar Cita</DialogTitle>
  582. <DialogDescription>
  583. Por favor proporciona un motivo para rechazar esta cita. El paciente recibirá esta información.
  584. </DialogDescription>
  585. </DialogHeader>
  586. <div className="space-y-2">
  587. <Label htmlFor="motivo">Motivo del rechazo</Label>
  588. <Textarea
  589. id="motivo"
  590. value={motivoRechazo}
  591. onChange={(e) => setMotivoRechazo(e.target.value)}
  592. placeholder="Ejemplo: No hay disponibilidad en esta fecha, por favor reagenda para la próxima semana..."
  593. rows={4}
  594. className="resize-none"
  595. />
  596. </div>
  597. <DialogFooter>
  598. <Button
  599. variant="outline"
  600. onClick={() => {
  601. setRejectDialog(false);
  602. setMotivoRechazo("");
  603. }}
  604. >
  605. Cancelar
  606. </Button>
  607. <Button
  608. variant="destructive"
  609. onClick={handleRejectConfirm}
  610. disabled={!motivoRechazo.trim() || actionLoading}
  611. >
  612. {actionLoading ? (
  613. <Loader2 className="h-4 w-4 mr-2 animate-spin" />
  614. ) : (
  615. <XCircle className="h-4 w-4 mr-2" />
  616. )}
  617. Rechazar Cita
  618. </Button>
  619. </DialogFooter>
  620. </DialogContent>
  621. </Dialog>
  622. {/* Approve Dialog */}
  623. <ApproveAppointmentModal
  624. open={approveDialog}
  625. onClose={() => setApproveDialog(false)}
  626. onConfirm={handleApprove}
  627. isLoading={actionLoading}
  628. />
  629. {/* Records Modal */}
  630. <RecordsModal
  631. isOpen={showRecordsModal}
  632. record={appointment?.record as Record || null}
  633. generatingPDF={false}
  634. onClose={() => setShowRecordsModal(false)}
  635. onCopyContent={handleCopyContent}
  636. onDownloadReport={handleDownloadReport}
  637. onGeneratePDF={handleGeneratePDF}
  638. />
  639. </div>
  640. </AuthenticatedLayout>
  641. );
  642. }